package utils;

import javafx.geometry.Point2D;
import javafx.scene.canvas.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.ArcType;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.TextAlignment;
import java.util.*;

/**
 * Graph visualization class.
 *
 * <p>You may add nodes and edges to this class, and they will be rendered as a
 * graph.</p>
 *
 * <p>The graph layout is based on the force-directed algorithm, which finds
 * aesthetically pleasing graph position by recurring to spring forces between
 * adjacent nodes, and repulsive forces against all others. The code is based on
 * code by <a href="http://chris.widdowson.id.au/?p=406">Chris Widdowson</a>.</p>
 *
 * @author Ricardo Cruz {@literal <ricardo.pdm.cruz@gmail.com>}
 * @since  2014-11-11
 */
public class GraphView extends ResizableCanvas
{
	private static final double STATE_SIZE = 40;
	private static final double STATE_PADDING = 120;

	private static class Edge {
		private String label;
		private Node end;
		public Edge(String label, Node end) {
			this.label = label;
			this.end = end;
		}
	}

	/**
	 * Create a node using {@link addNode} from GraphView and then you may use
	 * this class to plug a series of connections.
	 *
	 * @author Ricardo Cruz {@literal <ricardo.pdm.cruz@gmail.com>}
	 * @since  2014-11-11
	 */
	public static class Node {
		private String label;
		private Point2D pos = new Point2D(0,0);
		private Point2D vel = new Point2D(0,0);
		private LinkedList<Edge> edges = new LinkedList<Edge>();
		private Color color;

		protected Node(String label, Color color) {
			this.label = label;
			this.color = color;
		}

		/**
		 * Connect node to another.
		 *
		 * <p>Notice this is a directed graph; you must use {@link connectTo}
		 * for the other node if you want a two-way connection.</p>
		 *
		 * @param label for the edge line
		 * @param end destination node
		 */
		public void connectTo(String label, Node end) {
			Edge e = getConnectionTo(end);
			if(e == null)
				edges.add(new Edge(label, end));
			else
				e.label += "\n" + label;
		}

		private boolean isConnectedTo(Node end) {
			return getConnectionTo(end) != null;
		}

		private Edge getConnectionTo(Node end) {
			for(Edge e: this.edges)
				if(e.end == end)
					return e;
			return null;
		}
	}

	private LinkedList<Node> graph = new LinkedList<Node>();

	/**
	 * Adds a node to the graph, with a given label.
	 *
	 * <p>You may add transitions afterwards using {@link Node.connectTo}</p>
	 *
	 * @param label label of the new node
	 * @param color fill color for the node
	 * @return node reference
	 */
	public Node addNode(String label, Color color) {
		Node n = new Node(label, color);
		graph.add(n);
		return n;
	}

	/**
	 * Clears up the node.
	 */
	public void clear() {
		graph.clear();
	}

	/**
	 * Lays out the graph nodes aesthetically.
	 */
	public void layoutGraph() {
		initLayout();
		applyLayout();
		repaint();
	}

	protected void draw(GraphicsContext gc, double width, double height) {
		for(int i = 0; i < 20; i++)  // do a bit relayout in case size changes
			_applyLayout();
		snapToGrid();

		gc.setFill(Color.WHITE);
		gc.fillRect(0, 0, width, height);
		gc.setStroke(Color.GRAY);
		gc.strokeRect(0, 0, width, height);

		gc.setStroke(Color.BLACK);
		gc.setFill(Color.BLACK);
		gc.setLineWidth(1);

		for(Node v: graph) {
			paintState(gc, v.label, v.pos, v.color);
			for(Edge e: v.edges) {
				Node u = e.end;
				if(v == u)
					paintLoop(gc, e.label, v.pos);
				else {
					double x1 = v.pos.getX(), y1 = v.pos.getY();
					double x2 = u.pos.getX(), y2 = u.pos.getY();
					double padx = 0, pady = 0;
					final double PAD = 12;

					double m = (y2-y1)/(x2-x1);
					if(m > -1 && m < +1) {  // horizontal connection
						double s = Math.signum(u.pos.getX()-v.pos.getX());
						x1 += (STATE_SIZE/2+2)*s;
						x2 -= (STATE_SIZE/2+2)*s;
						padx = -PAD*s;
						gc.setTextAlign(s==-1 ? TextAlignment.LEFT : TextAlignment.RIGHT);
					}
					else {  // vertical connection
						double s = Math.signum(u.pos.getY()-v.pos.getY());
						y1 += (STATE_SIZE/2+2)*s;
						y2 -= (STATE_SIZE/2+2)*s;
						pady = -PAD*s;
						gc.setTextAlign(TextAlignment.CENTER);
					}

					// draw transition
					gc.beginPath();
					gc.moveTo(x1, y1);
					gc.lineTo(x2+padx, y2+pady);
					gc.lineTo(x2, y2);
					gc.stroke();
					paintArrow(gc, x2, y2, Math.atan2(-pady,-padx));
					gc.setFont(Font.font(9));
					gc.fillText(e.label, x2+padx*2, y2+pady*2-getTextHeight(e.label,9));
				}
			}
		}
    }

	private void paintState(GraphicsContext gc, String label, Point2D pos, Color fill) {
		gc.save();
		gc.setStroke(Color.GRAY);
		gc.strokeRoundRect(pos.getX()-STATE_SIZE/2+1, pos.getY()-STATE_SIZE/2+1, STATE_SIZE, STATE_SIZE, 24, 24);
		gc.setFill(fill);
		gc.fillRoundRect(pos.getX()-STATE_SIZE/2, pos.getY()-STATE_SIZE/2, STATE_SIZE, STATE_SIZE, 24, 24);
		gc.setStroke(Color.BLACK);
		gc.strokeRoundRect(pos.getX()-STATE_SIZE/2, pos.getY()-STATE_SIZE/2, STATE_SIZE, STATE_SIZE, 24, 24);
		gc.setFill(Color.BLACK);
		gc.setFont(Font.font(null, FontWeight.BOLD, 10));
		gc.setTextAlign(TextAlignment.CENTER);
		gc.fillText(label, pos.getX(),pos.getY()+4);
		gc.restore();
	}

	private void paintLoop(GraphicsContext gc, String label, Point2D pos) {
		double s = Math.signum(pos.getX()-getWidth()/2);
		final int RADIUS = 10;
		double x = pos.getX()+s*(STATE_SIZE/2)-RADIUS;
		double y = pos.getY()-(STATE_SIZE/2)-RADIUS;
		gc.strokeArc(x, y, RADIUS*2, RADIUS*2, 270, s*240, ArcType.OPEN);
		paintArrow(gc, x+(s==-1?RADIUS*2:0)-s*2, y+RADIUS-2, Math.PI/2+s*(Math.PI/4));
		gc.setTextAlign(TextAlignment.CENTER);
		gc.setFont(Font.font(9));
		gc.fillText(label, x+(s==-1?0:RADIUS*2), y-4-getTextHeight(label,9));
	}

	private static void paintArrow(GraphicsContext gc, double x, double y, double angle) {
		final double PHI = Math.toRadians(25);
		final int BARB = 8;
		double rho = angle + PHI;
		gc.beginPath();
		gc.moveTo(x, y);
		for(int j = 0; j < 2; j++) {
			gc.lineTo(x-BARB*Math.cos(rho), y-BARB*Math.sin(rho));
			rho = angle - PHI;
		}
		gc.closePath();
		gc.fill();
	}

	private int getTextHeight(String str, int size) {
		return (str.length() - str.replace("\n", "").length())*(size+1);
	}

	private void initLayout() {
		Random random = new Random();
		random.setSeed(6);
		for(Node v: graph)
			v.pos = new Point2D(random.nextDouble()*getWidth(), random.nextDouble()*getHeight());
	}

	private void applyLayout() {
		// iteration thresholds
		final double MIN_ENERGY = 4;
		final double MAX_ITERATION = 500;
		int iteration = 0;
		double energy;
		do {
			_applyLayout();
			energy = 0;
			for(Node v: graph)
				energy += v.vel.magnitude();
		} while(energy >= MIN_ENERGY && ++iteration < MAX_ITERATION);
		snapToGrid();
	}

/*	private void debugLayout() {
		new Thread(() -> {
			int iteration = 0;
			do {
				MyUtils.invokeAndWait(() -> {
					_applyLayout();
					repaint();
				});
				try { Thread.sleep(50); } catch (Exception e) {}
			} while(iteration++ < 1000);
		}).start();
	}*/

	// I was inspired mostly by the algorithm at:
	// http://chris.widdowson.id.au/?p=406

	private void _applyLayout() {
		final double K1 = 1;  // hooke's k
		final double K2 = 1e5;  // coloumb's k
		final double DAMPING = 0.9;  // friction for velocity
		final double DELTA_T = 0.1;
		for(Node v: graph) {
			for(Node u: graph) {
				if(v != u) {
					Point2D delta = v.pos.subtract(u.pos);
					Point2D dir = delta.normalize();
					double dist = delta.magnitude()+0.01;  // avoid 0 div

					Point2D force = new Point2D(0,0);
					if(v.isConnectedTo(u) || u.isConnectedTo(v)) {
						// attractive force (for adjacent nodes)
						// Hooke's law: F=k.x
						force = dir.multiply(-K1*((dist-STATE_PADDING)/2));
					}
					else
						// repulsive force
						// Coulomb's law: F=k.|q1.q2|/x^2
						force = dir.multiply(K2/(dist*dist));
					// acc = force (we assume mass1=mass2)
					v.vel = v.vel.add(force.multiply(DELTA_T));
				}
			}
			v.pos = v.pos.add(v.vel.multiply(DELTA_T));
			// clamp
			v.pos = new Point2D(
				Math.min(Math.max(v.pos.getX(),40),getWidth()-40),
				Math.min(Math.max(v.pos.getY(),40),getHeight()-40));
			v.vel = v.vel.multiply(DAMPING);
		}
	}

	private void snapToGrid() {
		for(Node v: graph)
			v.pos = new Point2D(Math.round(v.pos.getX()/40)*40, Math.round(v.pos.getY()/40)*40);
	}
}
